Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit ef2ee187e13d6243a23017607bad242ed564e36b


Parents : bd4ea14
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-08T10:29:29-05:00

feat(NomadNetwork): implement loading state for node searches with visual indicators and localization updates

Changes
Diff

diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
index e15befd8..8edd786e 100644
--- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
+++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
@@ -13,6 +13,7 @@
:nodes-search-term="nodesSearchTerm"
:total-nodes-count="totalNodesCount"
:is-loading-more-nodes="isLoadingMoreNodes"
+ :is-searching-nodes="isSearchingNodes"
:has-more-nodes="hasMoreNodes"
@node-click="onNodeClick"
@rename-favourite="onRenameFavourite"
@@ -675,6 +676,7 @@ export default {
totalNodesCount: 0,
hasMoreNodes: true,
isLoadingMoreNodes: false,
+ isSearchingNodes: false,
nodesSearchTerm: "",
pageSize: 50,
selectedNode: null,
@@ -1657,14 +1659,21 @@ export default {
}
},
async getNomadnetworkNodeAnnounces(append = false) {
+ // capture the controller that belongs to *this* call so a later,
+ // superseding call can't have its own finally block clear the
+ // loading state for an in-flight search out from under it.
+ let myController = this.nodesListAbortController;
try {
if (!append) {
if (this.nodesListAbortController) {
this.nodesListAbortController.abort();
}
this.nodesListAbortController = new AbortController();
+ myController = this.nodesListAbortController;
+ this.isSearchingNodes = true;
} else if (!this.nodesListAbortController) {
this.nodesListAbortController = new AbortController();
+ myController = this.nodesListAbortController;
}
const offset = append ? Object.keys(this.nodes).length : 0;
const response = await window.api.get(`/api/v1/announces`, {
@@ -1674,7 +1683,7 @@ export default {
offset: offset,
search: this.nodesSearchTerm,
},
- signal: this.nodesListAbortController.signal,
+ signal: myController.signal,
});
const nodeAnnounces = response.data.announces;
@@ -1694,6 +1703,9 @@ export default {
console.log(e);
} finally {
this.isLoadingMoreNodes = false;
+ if (!append && this.nodesListAbortController === myController) {
+ this.isSearchingNodes = false;
+ }
}
},
async loadMoreNodes() {
@@ -1703,6 +1715,7 @@ export default {
},
onNodesSearchChanged(term) {
this.nodesSearchTerm = term;
+ this.isSearchingNodes = true;
if (this.nodesRefreshTimeout) {
clearTimeout(this.nodesRefreshTimeout);
}

diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue
index 3b466cc7..667e4e44 100644
--- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue
+++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue
@@ -471,13 +471,23 @@
<div v-else class="flex-1 flex flex-col min-h-0">
<div class="p-3 border-b border-gray-200 dark:border-zinc-800 space-y-2">
<div class="flex gap-1.5 items-center">
- <input
- :value="nodesSearchTerm"
- type="text"
- :placeholder="$t('nomadnet.search_placeholder_announces', { count: totalNodesCount })"
- class="input-field flex-1 min-w-0 rounded-none"
- @input="onNodesSearchInput"
- />
+ <div class="relative flex-1 min-w-0">
+ <input
+ :value="nodesSearchTerm"
+ type="text"
+ :placeholder="$t('nomadnet.search_placeholder_announces', { count: totalNodesCount })"
+ class="input-field w-full min-w-0 rounded-none"
+ :class="{ 'pr-7': isSearchingNodes }"
+ @input="onNodesSearchInput"
+ />
+ <span
+ v-if="isSearchingNodes"
+ class="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-gray-400"
+ :title="$t('nomadnet.searching_announces')"
+ >
+ <MaterialDesignIcon icon-name="loading" class="size-4 animate-spin" />
+ </span>
+ </div>
<button
type="button"
class="shrink-0 self-center inline-flex items-center justify-center p-0.5 rounded-sm text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 transition-colors leading-none"
@@ -626,6 +636,17 @@
<MaterialDesignIcon icon-name="loading" class="size-6 animate-spin text-gray-400" />
</div>
</div>
+ <div
+ v-else-if="isSearchingNodes && nodesSearchTerm.trim() !== ''"
+ class="empty-state empty-state--panel"
+ >
+ <MaterialDesignIcon icon-name="loading" class="w-8 h-8 animate-spin" />
+ <div class="font-semibold">{{ $t("nomadnet.searching_announces") }}</div>
+ </div>
+ <div v-else-if="nodesSearchTerm.trim() !== ''" class="empty-state empty-state--panel">
+ <MaterialDesignIcon icon-name="radar" class="w-8 h-8" />
+ <div class="font-semibold">{{ $t("nomadnet.no_search_results_peers") }}</div>
+ </div>
<div v-else class="empty-state empty-state--panel">
<MaterialDesignIcon icon-name="radar" class="w-8 h-8" />
<div class="font-semibold">{{ $t("nomadnet.no_announces_yet") }}</div>
@@ -732,6 +753,10 @@ export default {
type: Boolean,
default: false,
},
+ isSearchingNodes: {
+ type: Boolean,
+ default: false,
+ },
hasMoreNodes: {
type: Boolean,
default: false,

diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index 2829a525..b5e5c392 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -1574,6 +1574,7 @@
"favourites_search_try_other": "Versuchen Sie einen anderen Suchbegriff.",
"no_favourites_in_section": "Keine Favoriten in diesem Abschnitt.",
"search_announces": "Ankündigungen durchsuchen",
+ "searching_announces": "Ankündigungen werden durchsucht...",
"announced_time_ago": "Vor {time} angekündigt",
"block_node": "Knoten blockieren",
"no_announces_yet": "Noch keine Ankündigungen",

diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index a08b26c5..e5273070 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -1686,6 +1686,7 @@
"favourites_search_try_other": "Try a different search term.",
"no_favourites_in_section": "No favourites in this section.",
"search_announces": "Search announces",
+ "searching_announces": "Searching announces...",
"announced_time_ago": "Announced {time} ago",
"block_node": "Banish Node",
"no_announces_yet": "No announces yet",

diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json
index 42a5dc4e..bed32b96 100644
--- a/meshchatx/src/frontend/locales/es.json
+++ b/meshchatx/src/frontend/locales/es.json
@@ -1686,6 +1686,7 @@
"favourites_search_try_other": "Pruebe con otro término de búsqueda.",
"no_favourites_in_section": "No hay favoritos en esta sección.",
"search_announces": "Búsqueda anuncia",
+ "searching_announces": "Buscando anuncios...",
"announced_time_ago": "Anunciado hace {time}",
"block_node": "Destierro",
"no_announces_yet": "No anuncia todavía",

diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json
index 331c0c9b..fbbcdec3 100644
--- a/meshchatx/src/frontend/locales/fi.json
+++ b/meshchatx/src/frontend/locales/fi.json
@@ -1686,6 +1686,7 @@
"favourites_search_try_other": "Kokeile toista hakusanaa.",
"no_favourites_in_section": "Ei suosikkeja osiossa.",
"search_announces": "Hae kuulutuksista",
+ "searching_announces": "Haetaan kuulutuksia...",
"announced_time_ago": "Kuulutettu {time} sitten",
"block_node": "Karkota solmu",
"no_announces_yet": "Ei kuulutuksia vielä",

diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json
index 219d468b..1b8e4871 100644
--- a/meshchatx/src/frontend/locales/fr.json
+++ b/meshchatx/src/frontend/locales/fr.json
@@ -1686,6 +1686,7 @@
"favourites_search_try_other": "Essayez un autre terme de recherche.",
"no_favourites_in_section": "Aucun favori dans cette section.",
"search_announces": "La recherche annonce",
+ "searching_announces": "Recherche des annonces...",
"announced_time_ago": "Annoncé il y a {time}",
"block_node": "Nœud baigné",
"no_announces_yet": "Pas encore d'annonce",

diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index e9bd46b7..ba92ee73 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -1738,6 +1738,7 @@
"favourites_search_try_other": "Prova un altro termine di ricerca.",
"no_favourites_in_section": "Nessun preferito in questa sezione.",
"search_announces": "Cerca annunci",
+ "searching_announces": "Ricerca annunci in corso...",
"announced_time_ago": "Annunciato {time} fa",
"block_node": "Esilia Nodo",
"no_announces_yet": "Ancora nessun annuncio",

diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json
index 57c37600..1a3cf73e 100644
--- a/meshchatx/src/frontend/locales/nl.json
+++ b/meshchatx/src/frontend/locales/nl.json
@@ -1686,6 +1686,7 @@
"favourites_search_try_other": "Probeer een andere zoekterm.",
"no_favourites_in_section": "Geen favorieten in deze sectie.",
"search_announces": "Zoekopdracht kondigt aan",
+ "searching_announces": "Aankondigingen zoeken...",
"announced_time_ago": "Aangekondigd {time} geleden",
"block_node": "Knooppunt verwijderen",
"no_announces_yet": "Nog geen aankondigingen",

diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index 6bf1a0bb..136bb4b9 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -1574,6 +1574,7 @@
"favourites_search_try_other": "Попробуйте другой поисковый запрос.",
"no_favourites_in_section": "В этом разделе нет избранного.",
"search_announces": "Поиск анонсов",
+ "searching_announces": "Поиск анонсов...",
"announced_time_ago": "Анонсировано {time} назад",
"block_node": "Заблокировать узел",
"no_announces_yet": "Анонсов пока нет",

diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json
index 5e2988cd..76a4e9df 100644
--- a/meshchatx/src/frontend/locales/zh.json
+++ b/meshchatx/src/frontend/locales/zh.json
@@ -1686,6 +1686,7 @@
"favourites_search_try_other": "请尝试其他搜索词。",
"no_favourites_in_section": "此分区中尚无收藏。",
"search_announces": "搜索广播",
+ "searching_announces": "正在搜索广播...",
"announced_time_ago": "{time} 前广播",
"block_node": "放逐节点",
"no_announces_yet": "尚无广播",

diff --git a/tests/frontend/NomadNetworkPage.test.js b/tests/frontend/NomadNetworkPage.test.js
index e135dec0..3d61f4d4 100644
--- a/tests/frontend/NomadNetworkPage.test.js
+++ b/tests/frontend/NomadNetworkPage.test.js
@@ -116,6 +116,84 @@ describe("NomadNetworkPage.vue", () => {
vi.useRealTimers();
});
+ it("tracks isSearchingNodes while the debounce and request are pending, clearing it once resolved", async () => {
+ vi.useFakeTimers();
+ axiosMock.isCancel = vi.fn(() => false);
+
+ let resolveAnnounces;
+ axiosMock.get.mockImplementation((url) => {
+ if (url === "/api/v1/favourites") return Promise.resolve({ data: { favourites: [] } });
+ if (url === "/api/v1/announces") {
+ return new Promise((resolve) => {
+ resolveAnnounces = resolve;
+ });
+ }
+ return Promise.resolve({ data: {} });
+ });
+
+ const wrapper = mountNomadNetworkPage();
+
+ // the initial mount fetch is in flight
+ expect(wrapper.vm.isSearchingNodes).toBe(true);
+ resolveAnnounces({ data: { announces: [], total_count: 0 } });
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.isSearchingNodes).toBe(false);
+
+ wrapper.vm.onNodesSearchChanged("nodequery");
+ // indicator shows immediately, even before the debounce fires
+ expect(wrapper.vm.isSearchingNodes).toBe(true);
+
+ await vi.advanceTimersByTimeAsync(500);
+ // debounce elapsed, request is now in flight and still pending
+ expect(wrapper.vm.isSearchingNodes).toBe(true);
+
+ resolveAnnounces({ data: { announces: [], total_count: 0 } });
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.isSearchingNodes).toBe(false);
+
+ vi.useRealTimers();
+ });
+
+ it("does not clear isSearchingNodes for a stale request superseded by a newer search", async () => {
+ vi.useFakeTimers();
+ axiosMock.isCancel = vi.fn((e) => e?.isCancelled === true);
+
+ const pending = [];
+ axiosMock.get.mockImplementation((url) => {
+ if (url === "/api/v1/favourites") return Promise.resolve({ data: { favourites: [] } });
+ if (url === "/api/v1/announces") {
+ return new Promise((resolve, reject) => {
+ pending.push({ resolve, reject });
+ });
+ }
+ return Promise.resolve({ data: {} });
+ });
+
+ const wrapper = mountNomadNetworkPage();
+ await wrapper.vm.$nextTick();
+ expect(pending.length).toBe(1);
+
+ // reject the initial mount fetch as if it was aborted by a new search
+ wrapper.vm.onNodesSearchChanged("first");
+ await vi.advanceTimersByTimeAsync(500);
+ expect(pending.length).toBe(2);
+ pending[0].reject({ isCancelled: true });
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.$nextTick();
+
+ // the newer request is still in flight, so the indicator must stay on
+ expect(wrapper.vm.isSearchingNodes).toBe(true);
+
+ pending[1].resolve({ data: { announces: [], total_count: 0 } });
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.isSearchingNodes).toBe(false);
+
+ vi.useRealTimers();
+ });
+
it("loads node when destinationHash prop is provided", async () => {
const destHash = "0123456789abcdef0123456789abcdef";
axiosMock.get.mockImplementation((url) => {

diff --git a/tests/frontend/NomadNetworkSidebar.test.js b/tests/frontend/NomadNetworkSidebar.test.js
index 29dc1b66..a28777b1 100644
--- a/tests/frontend/NomadNetworkSidebar.test.js
+++ b/tests/frontend/NomadNetworkSidebar.test.js
@@ -59,6 +59,7 @@ describe("NomadNetworkSidebar.vue", () => {
nodesSearchTerm: overrides.nodesSearchTerm ?? "",
totalNodesCount: overrides.totalNodesCount ?? 1,
isLoadingMoreNodes: overrides.isLoadingMoreNodes ?? false,
+ isSearchingNodes: overrides.isSearchingNodes ?? false,
hasMoreNodes: overrides.hasMoreNodes ?? false,
},
global: {
@@ -231,4 +232,70 @@ describe("NomadNetworkSidebar.vue", () => {
expect(emitSpy).toHaveBeenCalledWith("block-status-changed");
emitSpy.mockRestore();
});
+
+ it("shows a spinner next to the search input while a node search is in progress", async () => {
+ const wrapper = mountSidebar({ isSearchingNodes: true });
+ const announceTab = wrapper.findAll("button").find((b) => b.text().includes("nomadnet.announces"));
+ await announceTab.trigger("click");
+ await wrapper.vm.$nextTick();
+
+ const spinner = wrapper.find('[data-icon-name="loading"]');
+ expect(spinner.exists()).toBe(true);
+ });
+
+ it("does not show a search spinner when isSearchingNodes is false", async () => {
+ const wrapper = mountSidebar({ isSearchingNodes: false });
+ const announceTab = wrapper.findAll("button").find((b) => b.text().includes("nomadnet.announces"));
+ await announceTab.trigger("click");
+ await wrapper.vm.$nextTick();
+
+ const spinner = wrapper.find('[data-icon-name="loading"]');
+ expect(spinner.exists()).toBe(false);
+ });
+
+ it("shows a searching placeholder instead of the empty state while search results are still loading", async () => {
+ const wrapper = mountSidebar({
+ nodes: {},
+ totalNodesCount: 0,
+ nodesSearchTerm: "nonexistent",
+ isSearchingNodes: true,
+ });
+ const announceTab = wrapper.findAll("button").find((b) => b.text().includes("nomadnet.announces"));
+ await announceTab.trigger("click");
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.text()).toContain("nomadnet.searching_announces");
+ expect(wrapper.text()).not.toContain("nomadnet.no_announces_yet");
+ expect(wrapper.text()).not.toContain("nomadnet.no_search_results_peers");
+ });
+
+ it("shows the no-results-for-search message once a search finishes with no matches", async () => {
+ const wrapper = mountSidebar({
+ nodes: {},
+ totalNodesCount: 0,
+ nodesSearchTerm: "nonexistent",
+ isSearchingNodes: false,
+ });
+ const announceTab = wrapper.findAll("button").find((b) => b.text().includes("nomadnet.announces"));
+ await announceTab.trigger("click");
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.text()).toContain("nomadnet.no_search_results_peers");
+ expect(wrapper.text()).not.toContain("nomadnet.no_announces_yet");
+ });
+
+ it("shows the generic no-announces empty state when there is no search term", async () => {
+ const wrapper = mountSidebar({
+ nodes: {},
+ totalNodesCount: 0,
+ nodesSearchTerm: "",
+ isSearchingNodes: false,
+ });
+ const announceTab = wrapper.findAll("button").find((b) => b.text().includes("nomadnet.announces"));
+ await announceTab.trigger("click");
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.text()).toContain("nomadnet.no_announces_yet");
+ expect(wrapper.text()).not.toContain("nomadnet.no_search_results_peers");
+ });
});


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────